feat(auth): implement legacyFetchSignInWithEmail behaviour for Angular and React#1343
feat(auth): implement legacyFetchSignInWithEmail behaviour for Angular and React#1343russellwheatley wants to merge 37 commits into
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a robust mechanism for handling authentication errors where a user attempts to sign in with a different provider than previously used for the same email. It provides a new behavior that fetches and presents alternative sign-in methods, along with corresponding UI components for React and Angular, to guide users through the recovery process. This significantly improves the user experience by preventing dead ends during sign-in and offering clear paths to resolve credential conflicts. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new legacyFetchSignInWithEmail behavior to handle OAuth account mismatch flows, allowing users to recover their accounts by suggesting previously used sign-in methods. The changes include core logic for error handling and state management, new UI components for React and Angular to display recovery options, and updated translations. My feedback suggests a minor improvement to the attachEmailToError function to ensure safer cloning of the customData object.
jhuleatt
left a comment
There was a problem hiding this comment.
Overall LGTM, I just want to confirm that all defaults and demos have this legacy behavior disabled (except for the demo that explicitly shows this flow). This won't work by default on any Firebase project created after 2023
|
Hello, this PR has been open for more than 28 days with no activity. If you think this is a mistake, please comment to keep this open. Thanks for contributing!
|
setPendingState cleared legacySignInRecovery synchronously at the start of every sign-in attempt, so clicking a recovery button in the modal dismissed it immediately instead of on success, hiding any error if the attempt failed. Move the clear into handlePendingCredential, which only runs after sign-in actually succeeds.
… and behavior Extract the duplicated list of error codes that trigger the legacyFetchSignInWithEmail recovery flow into a single exported LEGACY_SIGN_IN_RECOVERY_ERROR_CODES constant plus an isLegacySignInRecoveryErrorCode() helper, used by both handleFirebaseError and buildRecovery so the two lists can't drift.
# Conflicts: # packages/core/src/auth.ts # packages/core/src/errors.ts
…in provider.ts FirebaseUIType from core already types legacySignInRecovery and clearLegacySignInRecovery correctly, so the local LegacySignInRecovery/FirebaseUIWithLegacyRecovery types and the `as FirebaseUIWithLegacyRecovery` casts were redundant. Now imports LegacySignInRecovery from @firebase-oss/ui-core and accesses the fields directly, matching the React package's approach.
Cover default and consumer-managed recovery flows in the React and Angular examples, including provider linking without duplicate accounts.
…nable legacyFetchSignInWithEmailHandler previously called setLegacySignInRecovery whenever fetchSignInMethodsForEmail resolved, even when there was nothing useful to recover to: - A password typo on a password-only account returns signInMethods: ["password"] - the SAME method the user just tried, not a different one. - An empty array (e.g. Email Enumeration Protection enabled) was preserved as-is. Both cases opened the recovery modal with misleading "different method" copy and no actionable sign-in buttons. Add hasActionableRecoveryMethod() and clear recovery state instead of setting it when no sign-in method differs from the one just attempted, mirroring the existing "no email"/"fetch failed" branches.
The "pendingCred" sessionStorage key literal was duplicated across legacy-fetch-sign-in-with-email.ts, errors.ts, and auth.ts with no shared source of truth, risking silent drift if one call site were renamed without the others. Export PENDING_CREDENTIAL_STORAGE_KEY from legacy-fetch-sign-in-with-email.ts and reuse it at every read/write site (and in the corresponding tests). Also comment the one remaining literal in the e2e spec, which can't import the constant since page.evaluate runs in the browser context.
…SignInWithEmail fetchSignInMethodsForEmail() only returns real data when Email Enumeration Protection is disabled on the Firebase project (the default for projects created after September 15, 2023 is enabled, making this behavior a no-op). When enumeration protection is off, this behavior also actively surfaces which sign-in methods exist for an email on every failed password attempt, not just OAuth conflicts, which directly opposes the enumeration protection Firebase's generic error codes otherwise provide. Add a security note to the README so integrators can make an informed choice before enabling it.
…overy UI The default legacy sign-in recovery UI rendered an OAuth button for any provider present in signInMethods, without checking it against attemptedProviderId. Add a defensive canOfferMethod() check (React) / canOfferMethod() (Angular) that excludes the just-attempted provider, so the recovery UI can never offer to "sign in with X" as the fix for a failed attempt with X, even under an edge case where Firebase's API includes it in signInMethods.
…redentials persistPendingCredential() stores a serialized OAuth credential (which may include access/ID tokens) in sessionStorage as plaintext. This is an accepted, pre-existing trade-off, not an oversight, so document it inline at both the write site (legacy-fetch-sign-in-with-email.ts) and read/consume site (auth.ts's handlePendingCredential), and call it out in the README next to the related Email Enumeration Protection note.
…th.ts auth.test.ts mocks ./errors at module scope, so it only proves handleFirebaseError was called - it can never prove the caller's promise actually rejects, since the mock never rejects. Every catch block in auth.ts used to call `handleFirebaseError(ui, error);` without `await`/`return`, which turned the rejection into an unhandled promise rejection instead of propagating it, silently resolving the caller with undefined. Add a test file that does NOT mock ./errors, exercising the real implementation end-to-end, so any future regression of `return await handleFirebaseError(...)` is caught by the test suite.
showLegacySignInRecovery defaulted to true on OAuthScreen and SignInAuthScreen in both React and Angular, so registering the legacyFetchSignInWithEmail behavior caused the recovery modal to render on every screen (not just the dedicated demo), addressing reviewer feedback that all defaults should have it disabled.
…idance Explains what the behavior does, links to the relevant Firebase docs on email/password vs. email-link auth and email enumeration protection, and recommends migrating away from it once protection is enabled.
…egacy recovery demo description Applies reviewer-suggested wording noting the demo only works on projects with email enumeration protection disabled.
…n protection caveat Extends the wording already applied to examples/react/src/routes.ts to the remaining route description and on-page copy for the Angular and React legacy recovery demo screens, for consistency.
…very abandon clearLegacySignInRecovery() only cleared in-memory recovery state, leaving a stale pendingCred in sessionStorage after dismiss/no-email/no-actionable-method/ fetch-error paths. Since handlePendingCredential runs on every successful sign-in, the stale credential could get silently linked to a later, unrelated sign-in in the same tab.
Add a per-Auth generation counter bumped by setLegacySignInRecovery/ clearLegacySignInRecovery on every state transition. The handler snapshots it before awaiting fetchSignInMethodsForEmail and bails out without touching the store if it's since moved on, so a superseded/duplicate in-flight call can no longer reopen a dismissed modal or clobber a newer recovery attempt.
…slation key The legacy sign-in recovery modal's eyebrow label rendered the literal string "Account Found" directly in both the React and Angular components, while every other string in the same modal was already routed through the translation system (getTranslation/injectTranslation). This made the label impossible to localize or override like its siblings (legacySignInRecoveryPrompt, legacySignInRecoverySelectMethod, etc.). Add a new optional legacySignInRecoveryAccountFound message key, defined only in en-us.ts (matching the existing precedent for this family of keys, which are not yet translated into the other 41 locales), and wire both components to read it through the same getTranslation/injectTranslation calls used elsewhere in each file. The rendered English copy is unchanged.
The "supports custom recovery UI and clears it when dismissed" test only asserted that the custom recovery UI disappeared after "Custom dismiss" was clicked, never that the pending OAuth credential in sessionStorage was actually removed. That gap is why the now-fixed High-severity bug (051be47) - a stale pendingCred surviving an abandoned recovery attempt and silently getting linked to a later, unrelated sign-in - shipped without being caught by CI. Add a precondition assertion (pendingCred is set before dismiss, mirroring the sibling test above it) and the actual regression-catching assertion (pendingCred is null after dismiss). clearLegacySignInRecovery() clears sessionStorage synchronously on click, so no expect.poll() is needed here, unlike the sibling test's async completeExistingGoogleSignIn path.
051be47 correctly made clearLegacySignInRecovery() also remove PENDING_CREDENTIAL_STORAGE_KEY from sessionStorage, so an abandoned/dismissed legacy sign-in recovery attempt can't leave a stale credential around. That fix is unchanged here and remains correct. It introduced a regression in the success path: handlePendingCredential() in auth.ts called ui.clearLegacySignInRecovery() first, then immediately tried to read that same sessionStorage key to rehydrate and link the pending credential. Since clearLegacySignInRecovery() now wipes it first, the read always returned null and linkWithCredential() silently stopped running on every successful sign-in that had a pending credential - reproduced by the e2e test "shows previous methods and links the pending OAuth credential" (react and angular-example projects), which failed with the linked account missing the github.com provider. Fix: read pendingCredString from sessionStorage before calling clearLegacySignInRecovery(), so the value is captured before it's cleared. The explicit removeItem() later in the function is now redundant with the removal clearLegacySignInRecovery() performs, but is kept as a safety net for callers that supply a clearLegacySignInRecovery() stub which doesn't touch sessionStorage (e.g. test doubles). Also adds a regression test that exercises the real clearLegacySignInRecovery() via initializeUI() (rather than the no-op mock createMockUI() uses elsewhere in this file), since the no-op mock is why the existing handlePendingCredential test suite didn't catch this ordering bug - it never touched sessionStorage in the first place. Confirmed no other call site clears legacy recovery state and then separately expects to read the pending credential afterward.
legacyFetchSignInWithEmailcore behavior: on OAuth conflicts (auth/account-exists-with-different-credential) and password-mismatch errors (auth/wrong-password,auth/invalid-credential,auth/invalid-login-credentials), it callsfetchSignInMethodsForEmail()and stores recovery state (email, available methods, attempted/pending provider) on the FirebaseUI instance — but only when there's an actually different method to offer, so a password typo or Email Enumeration Protection (empty methods) never pops up an empty dialog.auth.tsswallowed thrownFirebaseUIErrors — catch blocks calledhandleFirebaseError(ui, error);withoutawait/return, so sign-in/sign-up/phone-verification/MFA errors were never surfaced to callers. All call sites nowreturn await handleFirebaseError(...).sessionStorage(documented plaintext trade-off, pre-existing) so it can be linked once the user signs back in with their original method.LegacySignInRecovery) and Angular (fui-legacy-sign-in-recovery), plususeLegacySignInRecovery()/injectLegacySignInRecovery()for apps that want their own UI instead.showLegacySignInRecoveryonSignInAuthScreen/OAuthScreendefaults tofalse— opt in explicitly.e2e/tests/legacy-sign-in-recovery.spec.ts, react + angular) covering the full emulator-backed recovery-and-link flow and the custom/suppressed recovery UI.clearLegacySignInRecovery()now also clears the pending credential fromsessionStoragewhen recovery is abandoned (dismissed, no email, no actionable method, or a failed fetch), preventing a stale credential from later being linked to an unrelated sign-in.fetchSignInMethodsForEmailcall with a generation counter so a superseded/duplicate in-flight recovery attempt can't reopen a dismissed modal or clobber a newer attempt.sessionStorageafter a custom dismiss.closes #1313